A complete deep-dive into Kotlin's type system and function abstractions — every variant, every trade-off, every pros and cons. From how generics are erased at runtime to the difference between a SAM and an anonymous function, to building type-safe DSLs. Nothing omitted.
Without generics, every collection API returns Any — you lose type safety and must cast everywhere, with runtime ClassCastException risk. Generics let you write code that's parameterized over types: the same algorithm works for any type, but the compiler enforces correctness at each specific usage.
The JVM implements generics via type erasure: the type parameter exists only in the source and bytecode metadata, not in the actual runtime JVM instructions. At runtime, a List<String> is just a List. This was a Java backward-compatibility decision and Kotlin inherits it.
"Generics are a compile-time contract. The compiler uses them to enforce type safety, then erases them before handing the bytecode to the JVM."
// ✗ WITHOUT generics — unsafe, verbose, error-prone fun getFirst(list: List<Any>): Any = list[0] val s = getFirst(listOf("a")) as String // runtime cast, can throw // ✓ WITH generics — type-safe, no cast needed fun <T> getFirst(list: List<T>): T = list[0] val s: String = getFirst(listOf("a")) // compiler checks T = String // TYPE ERASURE: at runtime both are the same bytecode // List<String> == List<Int> == List (raw type) to the JVM val ls: List<String> = listOf("a") val li: List<Int> = listOf(1) println(ls.javaClass == li.javaClass) // true! Both are ArrayList // CONSEQUENCE: can't check generic types at runtime if (ls is List<String>) { } // ✗ Compile error: erased type if (ls is List<*>) { } // ✓ Star projection allowed
Type is inferred at each call site independently. Type parameter is scoped to a single invocation. Return type and parameter types can both reference T.
fun <T> wrap(v: T): List<T> = listOf(v) val ints = wrap(42) // T=Int val strs = wrap("hi") // T=String
Type is fixed when you create the instance: Box<String>. All methods on that instance share the same T. Enables stateful, type-safe containers.
class Box<T>(var value: T) { fun map(f: (T) -> T): Box<T> = Box(f(value)) } val b = Box(10) // T=Int val b2 = b.map{it*2}
Implementing classes can choose or fix the type. Enables type-safe polymorphism: different implementations with different T while sharing a common API shape.
interface Repo<T> { fun findById(id: Long): T? fun save(item: T) } class UserRepo: Repo<User> { override fun findById(...) = ... }
// 1. Single upper bound — T must subtype the bound fun <T : Comparable<T>> max(a: T, b: T): T = if (a > b) a else b // Without it, T is Any? — you can call nothing useful on T // 2. Multiple bounds — 'where' clause required fun <T> process(item: T): String where T : Serializable, T : Comparable<T> { return item.toString() } // 3. Non-nullable bound — forbids nullable type argument fun <T : Any> nonNull(v: T): T = v // nonNull(null) ✗ T must be non-nullable // nonNull<String?>() ✗ Same // 4. No bound — T is effectively Any? (nullable) fun <T> identity(v: T): T = v // Can call Any? methods: toString(), hashCode(), ==, etc. // 5. Recursive bound — T compared to itself class SortedList<T : Comparable<T>> { private val items = mutableListOf<T>() fun add(item: T) { items.add(item); items.sort() } } // 6. Multiple type parameters with cross-constraints fun <K : Comparable<K>, V> sortedMapOf( vararg pairs: Pair<K, V> ): Map<K, V> = sortedMapOf(*pairs)
When you don't know or don't care about the type argument, Kotlin uses star projection List<*>. This is roughly equivalent to Java's unbounded wildcard List<?>, but Kotlin's type system is more precise about what you can do with it.
// List<*> = List<out Any?>: can READ Any?, cannot WRITE fun printAll(list: List<*>) { list.forEach { println(it) } // ✓ read as Any? list.add("x") // ✗ can't add — type unknown } // MutableList<*> = MutableList<out Any?> for reads // Cannot call methods that take T as parameter val ml: MutableList<*> = mutableListOf("a", "b") val item = ml[0] // type is Any? — need cast to use ml.add("c") // ✗ Error: Nothing is passed as T // Map<*, String>: key type unknown, value is String // Map<String, *>: key is String, value type unknown fun valuesOf(map: Map<*, String>): List<String> = map.values.toList() // Nothing as lower bound: for out-projected write-only // Foo<in *> ≡ Foo<in Nothing>: can't pass any value
x is List<*> compiles fineAny? — requires cast to be usefulis T or T::class at runtime without reifiedT() doesn't compile — type unknown at runtimefun f(l: List<String>) and fun f(l: List<Int>) clash after erasureArray<T> is reified but List<T> is not — different rulesIf Dog is a subtype of Animal, is List<Dog> a subtype of List<Animal>? Intuitively yes — but it depends entirely on what you can do with the list. If you can only read from it, Dog-lists work as Animal-lists. If you can write to it, allowing Dog-lists as Animal-lists would let someone insert a Cat — breaking type safety.
Variance is how Kotlin (and the JVM) controls this. The default is invariant — no subtype relationship. You opt-in to covariance (out) or contravariance (in) when it's safe.
If Dog : Animal then Source<Dog> : Source<Animal>. Subtype relationship is preserved.
List<out T>, Flow<T>, Sequence<T>If Dog : Animal then Sink<Animal> : Sink<Dog>. Subtype relationship is reversed.
Comparator<in T>, Channel.send, ContinuationMutableList<Dog> is NOT related to MutableList<Animal>. No subtype relationship either way.
MutableList<T>, MutableMap<K,V>, Channel<T>You annotate the type parameter on the class/interface definition itself. Applies to all usages of that type everywhere. Best when the type is inherently a producer or consumer — like List (always read-only, always out T).
// OUT: Producer — T only in return positions class Producer<out T>(private val v: T) { fun get(): T = v // ✓ T in return // fun set(v: T) {} ✗ T in param — error } val dp: Producer<Dog> = Producer(Dog()) val ap: Producer<Animal> = dp // ✓ covariant // IN: Consumer — T only in parameter positions interface Consumer<in T> { fun consume(item: T) // ✓ T in param // fun produce(): T ✗ T in return — error } val ac: Consumer<Animal> = ... val dc: Consumer<Dog> = ac // ✓ contravariant // Real-world: Comparable<in T> // String : Comparable<String> // Comparator<in Animal> can sort Dogs (contravariance)
When you can't change the class definition (e.g. MutableList must be invariant), you apply variance at the usage site. You project the type to be read-only (out) or write-only (in) at that specific call.
// MutableList is invariant — can't pass Dog list as Animal list fun bad(animals: MutableList<Animal>) { ... } bad(mutableListOf(Dog(), Dog())) // ✗ Type mismatch // out projection: read-only view of MutableList fun printAll(list: MutableList<out Animal>) { list.forEach { println(it.name) } // ✓ read as Animal list.add(Cat()) // ✗ can't write } printAll(mutableListOf(Dog(), Dog())) // ✓ now compiles // in projection: write-only view fun fillDogs(dest: MutableList<in Dog>) { dest.add(Dog()) // ✓ write Dog // val d: Dog = dest[0] ✗ reads back as Any? } fillDogs(mutableListOf<Animal>()) // ✓ Animal accepts Dog // Kotlin copy() idiom (like Java wildcard copy) fun <T> copy(src: MutableList<out T>, dst: MutableList<in T>) { src.forEach { dst.add(it) } }
| Form | Syntax | Subtype rule | Can read T? | Can write T? | Stdlib example | When to use |
|---|---|---|---|---|---|---|
| Invariant | T | No relationship | ✓ Any | ✓ Any | MutableList | Default — mutable containers |
| Covariant (decl) | out T | Preserved ↓ | ✓ as T | ✗ | List, Flow | Read-only producers |
| Contravariant (decl) | in T | Reversed ↑ | ✗ | ✓ as T | Comparator | Write-only consumers |
| Out projection (use) | MutableList<out T> | Preserved for that call | ✓ as T | ✗ | copy() src param | Can't change class, need covariance |
| In projection (use) | MutableList<in T> | Reversed for that call | ✓ as Any? | ✓ as T | copy() dst param | Can't change class, need contravariance |
| Star projection | List<*> | Unknown T | ✓ as Any? | ✗ | printAll() | Type not known, don't care |
Every non-inlined lambda passed to a higher-order function creates a FunctionN object on the heap. Each call to that HOF: one allocation. In a forEach on a 10,000-element list, that's potentially thousands of calls going through a virtual dispatch on a Function object. In animation loops, RecyclerView binds, or hot coroutine paths, this matters.
inline solves this by instructing the compiler to paste the function body — and all inlined lambda bodies — directly at every call site. The call disappears; no object, no dispatch.
Large functions called many times inflate bytecode. Recursive functions can't be inlined. Public inline functions become part of the binary API and are harder to change. Use inline for: small HOFs taking lambdas, reified generics, non-local return scenarios.
// Source code inline fun measure(block: () -> Unit) { val t = System.nanoTime() block() println(System.nanoTime() - t) } measure { doWork() } // What the compiler generates at call site: // (no measure() call, no Function object) val t = System.nanoTime() doWork() // ← block body pasted println(System.nanoTime() - t) // Without inline — generated bytecode: // val fn = new Function0() { void invoke() { doWork(); } } // measure(fn) // → INVOKEVIRTUAL Function0.invoke (virtual dispatch) // → heap allocation for fn object
Pastes both function body and all lambda parameters at every call site. Enables non-local returns inside the inlined lambdas. Eliminates all FunctionN allocations for inlined lambdas.
Applied to a specific lambda param within an inline function. That lambda is NOT inlined — stays as a Function object. Use when you need to store the lambda, pass it to another function, or delay its invocation.
Lambda IS inlined (no allocation) but non-local returns are forbidden. Required when the lambda is called inside a different lambda context (Runnable, coroutine, callback) within the inline function body.
// ── inline: basic, enables non-local return ──────────────────────── inline fun forEach2<T>(list: List<T>, action: (T) -> Unit) { for (item in list) action(item) } fun findEven(list: List<Int>): Int? { list.forEach2 { if (it % 2 == 0) return it // ← NON-LOCAL: returns from findEven } return null } // ── noinline: must store lambda in property ──────────────────────── class EventBus { private var listener: ((String) -> Unit)? = null inline fun register( noinline onEvent: (String) -> Unit, // stored — must be noinline onRegister: () -> Unit // inlined ) { listener = onEvent // ✓ can store noinline lambda onRegister() // inlined here } } // ── crossinline: inlined but no non-local return ─────────────────── inline fun postOnMain(crossinline block: () -> Unit) { Handler(Looper.getMainLooper()).post { block() // invoked inside Runnable — crossinline required // if block could non-local-return, it would try to return // from an already-returned enclosing function — impossible } } postOnMain { updateUI() // return ✗ Compile error: non-local return not allowed here // return@postOnMain ✓ Labeled return from lambda OK } // ── Non-local return in non-inline: compile error ───────────────── fun nonInline(block: () -> Unit) { block() } fun demo() { nonInline { return // ✗ 'return' not allowed here — not inline } }
| Modifier | Lambda inlined? | FunctionN alloc? | Non-local return? | Can store lambda? | Use when |
|---|---|---|---|---|---|
| inline (no modifier) | ✓ Yes | ✓ Eliminated | ✓ Allowed | ✗ No | All HOF lambdas you don't need to store |
| noinline | ✗ No | ✗ Still allocated | ✗ Blocked | ✓ Yes | Lambda must be stored or passed to non-inline API |
| crossinline | ✓ Yes | ✓ Eliminated | ✗ Blocked | ✗ No | Lambda invoked inside another lambda/callback in body |
reified only works on inline functions. Because the compiler pastes the function body at each call site, it knows the exact type argument used at that call. It substitutes the real type into the pasted code — so T::class, value is T, and typeOf<T>() all work inside reified inline functions.
The alternative — passing KClass<T> explicitly — achieves the same thing but is more verbose at the call site and doesn't enable the is T check syntax directly.
"reified is not magic. It's the compiler doing the T→ActualType substitution you'd otherwise do manually by passing KClass<T> as a parameter."
// Source — what you write: inline fun <reified T> parseJson(json: String): T = gson.fromJson(json, T::class.java) val user = parseJson<User>(jsonString) // What the compiler generates at call site: // (T is substituted with User — the actual type) val user = gson.fromJson(jsonString, User::class.java) // No parseJson() call. No KClass parameter. Just the body, pasted. // The bytecode is equivalent to you having written it by hand. // This is why it's "zero runtime overhead" — it IS your code. // What happens with EACH different call site: val user = parseJson<User>(s) // pasted: ...User::class.java val product = parseJson<Product>(s) // pasted: ...Product::class.java val order = parseJson<Order>(s) // pasted: ...Order::class.java
// Must pass KClass explicitly — every call site verbose fun <T : Any> inject(clazz: KClass<T>): T = container.get(clazz) // Call site: verbose, error-prone val repo = inject(UserRepository::class) val svc = inject(EmailService::class) // Can't do 'is T' check — T is not the KClass, still erased fun <T : Any> filterType( list: List<*>, clazz: KClass<T> ): List<T> { @Suppress("UNCHECKED_CAST") return list.filter { clazz.isInstance(it) } as List<T> } // Call site: filterType(items, String::class)
// Reified: KClass is implicit, T IS the type at runtime inline fun <reified T> inject(): T = container.get(T::class) // Call site: clean, no class reference needed val repo = inject<UserRepository>() val svc = inject<EmailService>() // 'is T' check now works — T substituted at call site inline fun <reified T> List<*>.filterType(): List<T> = filterIsInstance<T>() // stdlib uses reified internally // Call site: val strings = items.filterType<String>() // Android ViewModel without boilerplate inline fun <reified VM : ViewModel> Fragment.viewModel(): VM = ViewModelProvider(this)[VM::class.java] // Usage: val vm: MyViewModel by viewModel()
| Capability | Regular generic T | KClass<T> param | reified T |
|---|---|---|---|
| T::class | ✗ Erased | ✓ clazz | ✓ Direct |
| value is T | ✗ Erased | ✓ clazz.isInstance(v) | ✓ Direct |
| T::class.java | ✗ | ✓ clazz.java | ✓ Direct |
| typeOf<T>() | ✗ | ✗ Partial | ✓ Full generic info |
| Call-site verbosity | Clean | Must pass ::class | Clean |
| No allocation overhead | ✓ | ✓ | ✓ (inline) |
| Works in non-inline fns | ✓ | ✓ | ✗ inline only |
| Full generic type token (List<T>) | ✗ | ✗ Class only | ✓ via typeOf |
is T, T::class, as T all work naturallyList<String>)it. Use for simple transforms. Avoid for nested lambdas (ambiguous which it).Every lambda type in Kotlin is an interface: (A) -> B compiles to Function1<A, B>. The JVM generates an anonymous class implementing this interface. The number suffix is the arity: Function0 through Function22. Beyond 22 params, Kotlin uses FunctionN with a vararg.
This means: every non-inlined lambda = one heap-allocated object. Capturing lambdas create a new object per usage context. Non-capturing lambdas are compiled to singletons (one object total). This distinction matters in hot paths.
// Function types and their FunctionN equivalents val f0: () -> Unit // Function0<Unit> val f1: (Int) -> String // Function1<Int, String> val f2: (Int, String) -> Bool // Function2<Int, String, Bool> // Kotlin suspending lambda type val sf: suspend () -> Unit // SuspendFunction0<Unit> // Lambda with receiver type val ext: String.() -> Int // Function1<String, Int> (receiver = first param) // ── Allocation analysis ──────────────────────────────────── // NON-CAPTURING: compiled to a SINGLETON object val pure = { x: Int -> x * 2 } // one object ever list.map { it * 2 } // reuses same singleton // CAPTURING: NEW object per call context fun multiplier(n: Int) = { x: Int -> x * n } val times3 = multiplier(3) // allocates object with n=3 val times5 = multiplier(5) // allocates another with n=5 // MUTABLE CAPTURE: captured variable wrapped in a Ref object var count = 0 val inc = { count++ } // count wrapped in IntRef() // Bytecode: val ref = new IntRef(); ref.element = 0 // { ref.element++ }
A closure is a lambda that captures variables from its enclosing scope. In Kotlin, unlike Java, you can capture and mutate local variables (Java only allows effectively-final). This is implemented by wrapping the variable in a Ref object on the heap.
// IMMUTABLE CAPTURE — val captured directly in lambda val prefix = "Hello" val greet: (String) -> String = { "$prefix, $it" } // 'prefix' is a String (immutable), copied into lambda object field // MUTABLE CAPTURE — var wrapped in Ref by compiler var total = 0 val items = listOf(1, 2, 3) items.forEach { total += it } // captures and mutates total println(total) // 6 // Compiled as: val totalRef = IntRef(); totalRef.element = 0 // lambda: { totalRef.element += it } // COMMON PITFALL: capture in loop val fns = mutableListOf<() -> Int>() for (i in 0..2) { fns.add { i } // each lambda captures the IntRef of i } // All fns reference the SAME IntRef. After loop, i=3. fns.map { it() } // [0, 1, 2] — actually OK, range is immutable // But with var i = 0; while(i < 3): fns would all return 3! // SOLUTION for the pitfall: capture a snapshot for (i in 0..2) { val snapshot = i // new immutable capture each iteration fns.add { snapshot } }
.map{}.filter{}.fold{} read naturallyreturn exits the enclosing function, not just the lambdait require explicit namingSAM (Single Abstract Method) conversion allows passing a lambda where a functional interface is expected — any interface with exactly one abstract method. The compiler automatically wraps your lambda in an anonymous class implementing that interface.
Java has had this since Java 8 — it's why you can write button.setOnClickListener { view -> ... } instead of button.setOnClickListener(new View.OnClickListener() { ... }). Kotlin extends this with fun interface for defining your own SAM types.
// ── Java SAM: Runnable ──────────────────────────────────── // Java: executor.execute(new Runnable() { public void run() { } }) executor.execute { println("hello") } // SAM conversion executor.execute(Runnable { println("hello") }) // explicit // Comparator<in T> — Java SAM with generic val comparator: Comparator<String> = Comparator { a, b -> a.length - b.length } // Or concisely: listOf("banana", "fig", "apple").sortedWith { a, b -> a.length - b.length } // ── Kotlin fun interface ────────────────────────────────── fun interface Transformer<T, R> { fun transform(input: T): R // single abstract method fun andThen(other: Transformer<R, R>) // non-abstract: allowed : Transformer<T, R> = Transformer { other.transform(transform(it)) } } val double: Transformer<Int, Int> = { it * 2 } // SAM conversion println(double.transform(5)) // 10
Works automatically with any Java functional interface (Runnable, Callable, Comparator, etc). The compiler generates the anonymous class at the call site.
Kotlin's own SAM interface. Has a name, can have default method implementations, can extend other interfaces, can have properties. Lambda-convertible at call sites.
The lightest option. No named type, just a callable signature. Cannot have default methods. Directly assignable, no wrapper class needed. The standard Kotlin way to pass behavior.
| Feature | Java SAM | Kotlin fun interface | Function type (A)→B |
|---|---|---|---|
| Lambda conversion | ✓ Automatic | ✓ Automatic | ✓ Always |
| Named type | ✓ Has name | ✓ Has name | ✗ Anonymous |
| Default methods | ✓ Java default | ✓ fun impls | ✗ None |
| Extension functions | ✓ | ✓ | ✓ On FunctionN |
| Kotlin interface | ✗ Java only | ✓ | ✓ FunctionN |
| Java interop (call from Java) | ✓ Native | ✓ | ✓ As FunctionN |
| inline-able | ✗ | ✗ | ✓ |
| Type alias possible | ✗ | ✗ not needed | ✓ typealias |
An anonymous function is a function literal written with the fun keyword but without a name. It looks like a regular function you can assign or pass. The only thing that truly sets it apart from a lambda is return semantics.
In a lambda, an unlabeled return is a non-local return — it exits the enclosing named function, not just the lambda. This only works inside inline functions. In an anonymous function, return always exits the anonymous function itself — local, predictable, no surprises.
"Choose anonymous function when you have multiple return points and labeled returns feel unnatural. Choose lambda everywhere else."
// Basic anonymous function val double = fun(x: Int): Int { return x * 2 } // Expression form (single expression) val triple = fun(x: Int) = x * 3 // With explicit return type declaration — impossible with lambda val safeDivide = fun(a: Int, b: Int): Double? { if (b == 0) return null // exits anonymous function if (a == b) return 1.0 // exits anonymous function return a.toDouble() / b // exits anonymous function } // Passing as argument listOf(1, 2, 3).filter(fun(it): Boolean { return it > 1 // exits filter predicate, not enclosing function }) // Stored in variable — identical type signature to lambda val fn: (Int) -> Int = fun(x: Int): Int { return x + 1 } val la: (Int) -> Int = { x -> x + 1 } // exactly same type
fun demo(items: List<Int>): String { // ── CASE 1: Lambda inside INLINE function ───────────────────────────── // forEach is inline, so return is non-local — exits 'demo' items.forEach { if (it == 0) return "found zero" // ← exits 'demo'! } // ── CASE 2: Lambda with label — local return ────────────────────────── items.forEach forEachLabel@{ if (it == 0) return@forEachLabel // ← exits current forEach iteration only println(it) } // ── CASE 3: Implicit label — cleaner syntax ─────────────────────────── items.forEach { if (it == 0) return@forEach // ← implicit label = function name println(it) } // ── CASE 4: Anonymous function — always local return ────────────────── items.forEach(fun(item: Int) { if (item == 0) return // ← exits THIS anonymous function only, not 'demo' println(item) // completely equivalent to Case 3 semantically }) // ── CASE 5: Lambda inside NON-INLINE — non-local return is error ────── val nonInlineFn: (() -> Unit) -> Unit = { block -> block() } nonInlineFn { return "error" // ✗ Compile error: 'return' not allowed here // Must use: return@nonInlineFn or labeled return } // ── CASE 6: Coroutine launch — non-local return forbidden ───────────── val job = scope.launch { // launch is inline but block is crossinline return@demo // ✗ Can't return from coroutine to outer function // return@launch ✓ exits the coroutine block } return "done" }
| Scenario | Return syntax | Exits | Requires inline? | Notes |
|---|---|---|---|---|
| Lambda in inline fn | return value | Enclosing named function | ✓ Yes | Non-local return — powerful but surprising |
| Lambda — labeled | return@label value | Current lambda only | ✗ No | Works in both inline and non-inline |
| Lambda — implicit label | return@fnName | Current lambda only | ✗ No | Label = the HOF function name |
| Anonymous function | return value | Anonymous function only | ✗ No | Always local — predictable |
| Lambda in non-inline | return | ✗ Compile error | N/A | Non-local return not allowed |
(): Double? for clarity — impossible with lambdafun syntaxA lambda with receiver is a function type where one extra "receiver" object is implicitly available as this inside the lambda. The type is written T.() -> R. Calling such a lambda on an object T lets you access all its members without qualification.
This is the entire mechanism behind Kotlin's DSL capability: you create a builder class, define methods on it, then write a builder function that takes a BuilderClass.() -> Unit lambda and calls it on a new instance. The lambda body then looks like a mini-language.
// Extension function: calls obj.doSomething() fun StringBuilder.addHello() = append("hello") // Lambda with receiver: same access, but as a value val addHello: StringBuilder.() -> Unit = { append("hello") } // These are semantically equivalent. The lambda is a function // value with StringBuilder as its implicit 'this'. // Calling a receiver lambda: val sb = StringBuilder() sb.addHello() // extension function call sb.addHello() // receiver lambda called on sb // OR: addHello(sb) — receiver is first parameter under the hood // The stdlib 'apply' is just this pattern: inline fun <T> T.apply(block: T.() -> Unit): T { block() // 'this' inside block = the T receiver return this } // Usage: creates new AlertDialog with configuration AlertDialog.Builder(ctx).apply { setTitle("Warning") // this = Builder, no 'this.' needed setMessage("Sure?") }.create()
// ── apply: T.(T.() → Unit) → T ───────────────────────────────────── // Context: this=T, returns T. Use: object configuration inline fun <T> T.apply(block: T.() -> Unit): T { block(); return this } val user = User().apply { name = "Alice" // this.name — receiver is User instance age = 30 } // returns the User itself // ── with: (T, T.() → R) → R ───────────────────────────────────────── // Context: this=T, returns R. Use: multiple operations, return result inline fun <T, R> with(receiver: T, block: T.() -> R): R = receiver.block() val json = with(StringBuilder()) { append("""{"name":"""") append(user.name) append(""""}""") toString() // ← return value } // ── run: T.(T.() → R) → R ─────────────────────────────────────────── // Context: this=T, returns R. Like with but as extension function inline fun <T, R> T.run(block: T.() -> R): R = block() val validated = user.run { require(name.isNotEmpty()) { "Name required" } // this = user copy(name = name.trim()) // ← return transformed copy } // ── let: T.((T) → R) → R ───────────────────────────────────────── // Context: it=T (parameter), returns R. Use: null-safe chain, transform inline fun <T, R> T.let(block: (T) -> R): R = block(this) user.email?.let { email -> // only runs if email != null sendConfirmation(email) // email is non-null here } // ── also: T.((T) → Unit) → T ───────────────────────────────────── // Context: it=T, returns T. Use: side effects in chain inline fun <T> T.also(block: (T) -> Unit): T { block(this); return this } val saved = repository.save(user) .also { log.info("Saved: ${it.id}") } // side effect .also { analytics.track(it) } // another side effect // user is passed through unchanged
| Function | Context object | Return value | Extension? | Inline? | Best for |
|---|---|---|---|---|---|
| apply | this = T | T (receiver) | ✓ | ✓ | Object initialization, builder-style configuration |
| with | this = T | R (result) | ✗ (fn param) | ✓ | Multiple operations on same object, compute result |
| run | this = T | R (result) | ✓ | ✓ | Config + compute result; null-safe version of with |
| let | it = T | R (result) | ✓ | ✓ | Null checks, rename object, transform to different type |
| also | it = T | T (receiver) | ✓ | ✓ | Side effects (logging, tracking) in method chains |
Without @DslMarker, inside a nested DSL block you can accidentally call methods from an outer receiver. This is confusing and a source of bugs. @DslMarker groups receiver types — if the implicit receiver of an outer scope and the current scope share the same marker, the outer receiver's methods are hidden.
@DslMarker @Target(AnnotationTarget.CLASS) annotation class HtmlDsl @HtmlDsl class Table { fun tr(init: Tr.() -> Unit) { Tr().apply(init) } } @HtmlDsl class Tr { fun td(text: String) { println("<td>$text</td>") } } fun table(init: Table.() -> Unit) = Table().apply(init) table { tr { td("cell") // ✓ Tr.td() — correct tr { } // ✗ Error: can't access Table.tr() from Tr scope // Without @DslMarker, this would compile and be confusing } } // Without @DslMarker — the problem: table { tr { tr { } // Would call Table.tr() from inside Tr — confusing! } }
// ── 1. Define the DSL marker ───────────────────────────────────────── @DslMarker @Target(AnnotationTarget.CLASS) annotation class RequestDsl // ── 2. Define builder classes ──────────────────────────────────────── @RequestDsl class HeadersBuilder { private val headers = mutableMapOf<String, String>() infix fun String.to(value: String) { headers[this] = value } fun build() = headers.toMap() } @RequestDsl class RequestBuilder { var url: String = "" var method: Method = Method.GET private var headers: Map<String, String> = emptyMap() private var body: String? = null fun headers(init: HeadersBuilder.() -> Unit) { headers = HeadersBuilder().apply(init).build() } fun jsonBody(json: String) { body = json // Can't call headers{} here: @DslMarker prevents it if needed } fun build() = Request(url, method, headers, body) } // ── 3. Entry-point builder function ───────────────────────────────── fun request(init: RequestBuilder.() -> Unit): Request = RequestBuilder().apply(init).build() // ── 4. Usage: reads like configuration, is fully type-checked ──────── val req = request { url = "https://api.example.com/users" method = Method.POST headers { "Authorization" to "Bearer $token" "Content-Type" to "application/json" url = "hack" // ✗ @DslMarker: can't access RequestBuilder.url from HeadersBuilder scope } jsonBody("""{"name": "Alice", "role": "admin"}""") } // ── 5. Gradle-style: mixing receivers and infix operators ──────────── fun dependencies(init: DependencyHandler.() -> Unit) = ... dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib") testImplementation("junit:junit:4.13") // Each of these is a method call on DependencyHandler }
inline — zero overhead| Feature | What it is | Key rule / constraint | Main trade-off | Use when |
|---|---|---|---|---|
| Generics <T> | Compile-time type param, erased to Any? at runtime | No is T or T::class without reified | Safety vs runtime ignorance | Type-safe containers, algorithms |
| Upper bound T:X | Constrains allowed types to subtypes of X | Without bound, T = Any? — call nothing | Flexibility vs operations available | Algorithms needing specific interface |
| Star projection * | Unknown type argument — read Any?, can't write | Not same as Any — no writing allowed | Safety at cost of usability | Printing, reflection, type checks |
| out T (covariant) | Producer — subtype preserved | T only in return positions | Flexibility vs write restriction | Read-only APIs: List, Flow |
| in T (contravariant) | Consumer — subtype reversed | T only in param positions | Flexibility vs read restriction | Callbacks, Comparator, Channel.send |
| Invariant T | No subtype relationship | Must match exactly | Safety vs flexibility | Mutable containers: MutableList |
| inline fun | Body + lambdas pasted at call site | Can't be recursive; binary size grows | Perf vs code size | HOFs with lambdas, reified, hot paths |
| noinline | Specific lambda not inlined | Lambda stays as FunctionN object | Storability vs allocation | Lambda must be stored or passed on |
| crossinline | Inlined but no non-local return | Lambda called inside another lambda | Perf vs return restriction | Async wrappers, Handler.post |
| reified T | T substituted at inline call site | Only on inline; Java can't call it | Ergonomics vs inline-only constraint | Type checks, JSON, DI, filterIsInstance |
| Lambda { } | Anonymous function literal as value | Unlabeled return = non-local in inline | Conciseness vs return semantics | Default choice for HOF arguments |
| fun interface | Kotlin SAM — one abstract method | Exactly one abstract method | Named type vs function type simplicity | Named callbacks, Java interop |
| Anonymous fun | fun keyword, no name, local return | return always local; no trailing syntax | Predictable returns vs trailing syntax | Multi-return-point, explicit return type |
| T.() → R (receiver) | Lambda where this=T inside | @DslMarker needed for nesting safety | Readability vs learning curve | Builders, DSLs, scope functions |